All files / src/app/collections/[id] page.tsx

97.22% Statements 35/36
91.93% Branches 57/62
100% Functions 9/9
96.87% Lines 31/32

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211                                                                      29x 29x 29x 29x       29x   29x     27x 29x 29x       29x             27x 29x   29x 19x   29x 19x 19x   29x   29x 29x   29x   29x 29x 29x     29x   29x 29x 29x   29x                                                                                                               5x                                                                       19x                                                            
import { getSession } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getDb } from "@/lib/db";
import { requireCollectionAccess } from "@/lib/collection-access";
import { PhotoController } from "@/server/api/photos";
import { ItemController } from "@/server/api/items";
import { SessionController } from "@/server/api/sessions";
import { PreferenceController } from "@/server/api/preferences";
import { getPhotoAssetUrlForPhoto, PHOTO_WIDTH } from "@/lib/photos";
import { AppLayout } from "@/components/layout";
import { ActionBar } from "@/components/layout/ActionBar";
import { trail } from "@/components/layout/breadcrumb-trails";
import { Button, EmptyState, Input, buttonVariants } from "@/components/ui";
import { viewHref } from "@/components/ui/viewHref";
import { KeepSortingButton } from "@/features/sessions/KeepSortingButton";
import { PhotoPoolPreview } from "@/features/collections/PhotoPoolPreview";
import { CollectionHomeMenu } from "@/features/collections/CollectionHomeMenu";
import { ItemCard } from "@/features/items/ItemCard";
import { ArchivedItemCard } from "@/features/items/ArchivedItemCard";
import { ArchivedNotice } from "@/features/items/ArchivedNotice";
import { ItemFilterPills, type ItemsView } from "@/features/items/ItemFilterPills";
import { filterItems, itemsEmptyState } from "@/lib/items";
import { isIdShaped } from "@/lib/id";
import { queryFrom } from "@/lib/searchParam";
import { quietLink } from "@/lib/design/quiet-link";
import Link from "next/link";
 
type Param = string | string[] | undefined;
 
interface CollectionPageProps {
  params: Promise<{ id: string }>;
  searchParams?: Promise<{ status?: Param; unnamed?: Param; removed?: Param; q?: Param }>;
}
 
export default async function CollectionPage({ params, searchParams: searchParamsPromise }: CollectionPageProps) {
  const { id } = await params;
  const searchParams = await searchParamsPromise;
  const session = await getSession();
  Iif (!session?.user) {
    redirect("/login");
  }
 
  const db = getDb();
  // Access is enforced BEFORE any read — a thrown redirect here means nothing below runs.
  const collection = await requireCollectionAccess(db, id, session.user.id);
 
  const view: ItemsView =
    searchParams?.status === "archived" ? "archived" : searchParams?.unnamed === "1" ? "unnamed" : "all";
  const q = queryFrom(searchParams?.q);
  const itemController = ItemController.fromDb(db);
  // One round-trip. A failed read PROPAGATES (never caught into [] and shown as empty).
  // Archived items are always read (this collection's only): they verify ?removed= and
  // tell "nothing yet" from "everything archived".
  const [pool, liveItems, archivedItems, sessions, preferences] = await Promise.all([
    PhotoController.fromDb(db).findPoolByCollection(collection.id, 6),
    itemController.listItems(collection.id, { orderBy: "number" }),
    itemController.listArchived(collection.id),
    SessionController.fromDb(db).listByCollectionAndCurator(collection.id, session.user.id),
    PreferenceController.fromDb(db).listItemPreferences(collection.id, session.user.id),
  ]);
  const items = view === "archived" ? archivedItems : liveItems;
  const visible = filterItems(items, { unnamed: view === "unnamed", q });
  const covers =
    visible.length > 0
      ? await PhotoController.fromDb(db).findCoverPhotosByItemIds(visible.map((i) => i.id))
      : new Map();
  const coverUrl = (itemId: string) => {
    const c = covers.get(itemId);
    return c ? getPhotoAssetUrlForPhoto(c, collection.id, { w: PHOTO_WIDTH.card }) : undefined;
  };
  const tierByItem = new Map(preferences.map((p) => [p.itemId, p.rankBucket]));
 
  const base = `/collections/${collection.slug ?? collection.id}`;
  const poolHref = `${base}/pool`;
  // purses-t9l: each user sorts independently. A paused sort is still the current one.
  const currentSession = sessions.find((s) => s.status === "active" || s.status === "paused");
  // Something to see on the Ranking: a placed piece, or a sort finished before.
  const hasEverSorted = preferences.length > 0 || sessions.some((s) => s.status === "completed");
  const canSort = liveItems.length > 0 || currentSession !== undefined;
  const showPool = pool.length > 0 && view === "all" && !q;
  // The archive undo (purses-v7gv): only for an id-shaped value that IS an archived item
  // of THIS collection — a stale, foreign or hostile ?removed= renders nothing.
  const removed = searchParams?.removed;
  const justArchived =
    view !== "archived" && isIdShaped(removed) && archivedItems.some((i) => i.id === removed) ? removed : null;
  const empty = itemsEmptyState({ hasAnyItems: items.length > 0, hasArchived: archivedItems.length > 0, view, q });
  const poolOnly = showPool && liveItems.length === 0;
 
  return (
    <AppLayout breadcrumbs={trail.collection(collection)} reserveActionBar>
      {/* Tighter on phone so a card row shows above the fold at 390×664. */}
      <div className="space-y-4 md:space-y-8">
        {/* The header group: title, ··· and (tablet) See your ranking held together; the action row breaks after it. */}
        <div className="space-y-2">
          <header className="flex items-start justify-between gap-3">
            <div className="min-w-0 max-w-3xl">
              {/* tabIndex -1: ArchivedNotice hands focus here as it unmounts. */}
              <h1 tabIndex={-1} className="text-2xl text-ink outline-hidden sm:text-3xl">{collection.name}</h1>
              {collection.description && <p className="mt-2 text-ink-soft">{collection.description}</p>}
            </div>
            <CollectionHomeMenu href={base} name={collection.name} isOwner={collection.ownerId === session.user.id} />
          </header>
          {hasEverSorted && (
            <div className="max-md:hidden">
              <Link href={`${base}/results`} className={quietLink}>
                See your ranking
              </Link>
            </div>
          )}
        </div>
 
        {/* Spec §5: the one primary action, Add photos beside it. Start sorting needs
            something to sort; before that the primary is the way to items. */}
        <ActionBar label="Next step">
          {canSort ? (
            <KeepSortingButton
              fullWidth
              collectionId={collection.id}
              curatorId={session.user.id}
              sortHref={`${base}/sort`}
              session={
                currentSession
                  ? { id: currentSession.id, status: currentSession.status as "active" | "paused" }
                  : null
              }
            />
          ) : (
            <Link href={poolHref} className={buttonVariants()}>
              {pool.length > 0 ? "Make items" : "Add photos"}
            </Link>
          )}
          {canSort && (
            <Link href={poolHref} className={buttonVariants({ variant: "secondary" })}>
              Add photos
            </Link>
          )}
        </ActionBar>
 
        {justArchived && <ArchivedNotice kind="item" id={justArchived} />}
 
        {showPool && (
          <PhotoPoolPreview
            collectionId={collection.id}
            poolHref={poolHref}
            photos={pool.map((p) => ({ id: p.id }))}
            showLink={canSort}
          />
        )}
 
        {/* Nothing to search or filter until an item exists, live or archived. */}
        {liveItems.length + archivedItems.length > 0 && (
          <div className="space-y-4">
            <form role="search" action={base} className="flex items-end gap-2">
              {view === "archived" && <input type="hidden" name="status" value="archived" />}
              {view === "unnamed" && <input type="hidden" name="unnamed" value="1" />}
              <div className="w-full">
                {/* Visually hidden below md (above-the-fold room); the accessible name stays. */}
                <label htmlFor="home-search" className="mb-2 block text-sm font-medium text-ink max-md:sr-only">
                  Search your items
                </label>
                <Input
                  id="home-search"
                  type="search"
                  name="q"
                  defaultValue={q}
                  placeholder="Name, brand, color…"
                  maxLength={100}
                />
              </div>
              <Button type="submit" variant="secondary" className="h-12">
                Search
              </Button>
            </form>
            <ItemFilterPills path={base} view={view} q={q} />
          </div>
        )}
 
        {visible.length > 0 ? (
          <ul aria-label="Your items" className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
            {visible.map((item) => (
              <li key={item.id}>
                {view === "archived" ? (
                  <ArchivedItemCard item={item} coverUrl={coverUrl(item.id)} />
                ) : (
                  <ItemCard
                    item={item}
                    collectionId={collection.slug ?? collection.id}
                    heroPhotoUrl={coverUrl(item.id)}
                    bucket={tierByItem.get(item.id)}
                  />
                )}
              </li>
            ))}
          </ul>
        ) : (
          !poolOnly && (
            <div className="flex flex-col items-center">
              <EmptyState heading={empty.heading} message={empty.message} />
              {q && (
                <Link href={viewHref(base, view, "")} className={quietLink}>
                  Clear search
                </Link>
              )}
            </div>
          )
        )}
      </div>
    </AppLayout>
  );
}